Skip to content

Note

Click here to download the full example code

sequential indicator simulation

sequential indicator simulation (SIS) draws categories. it visits the nodes along a random path, krigs the indicator of each category with its own variogram from the samples and the nodes already drawn, and draws a category from those probabilities. each realization is one possible rock-type map; over many realizations, the frequency of each category at a node is its probability. categorical indicator kriging estimates such probabilities without simulating.

import sys
from pathlib import Path

HERE = Path(__file__).parent if "__file__" in globals() else Path.cwd()
sys.path.insert(0, str(HERE.parents[1]))
import boitata as bt
import matplotlib.pyplot as plt
import numpy as np
from common import ACCENT, GRAY, save

rock types

jura's rock types are known at each node of the prediction grid, so you can compare simulated maps with the real geology. a Categories scheme names the five types and gives each a color: encode turns labels into the codes 0 to 4 that SIS takes, and shares gives their proportions.

jura = bt.datasets.jura()
train, grid = jura["prediction"], jura["grid"]
names = ["Argovian", "Kimmeridgian", "Sequanian", "Portlandian", "Quaternary"]
rock_types = bt.Categories(names, colors=["#1f4e79", "#6f9fc9", "#c9d9ea", "#c05a28", "#8c8c8c"])
rock = rock_types.encode(train["Rock"]).astype(int)
true_rock = rock_types.encode(grid["Rock"]).astype(int)
print(f"{len(rock)} samples, {len(true_rock)} grid nodes")
print(f"{'':<13}{'samples':>8}{'share':>7}{'true grid':>11}")
for k, name in enumerate(names):
    print(f"{name:<13}{np.sum(rock == k):8d}{np.mean(rock == k):7.2f}{np.mean(true_rock == k):11.2f}")

Out:

259 samples, 5957 grid nodes
              samples  share  true grid
Argovian           53   0.20       0.20
Kimmeridgian       85   0.33       0.34
Sequanian          63   0.24       0.27
Portlandian         3   0.01       0.05
Quaternary         55   0.21       0.13

indicator variograms

each rock type gets the variogram of its indicator, 1 inside the type and 0 outside. portlandian has too few samples for an experimental variogram, so it gets a short spherical model with the indicator's variance as sill.

experimental, variograms = [], []
for k in range(5):
    indicator = (rock == k).astype(float)
    if indicator.sum() >= 5:
        experimental.append(bt.experimental_variogram(train.coords, indicator, 0.1, 1.5))
        variograms.append(experimental[-1].fit("spherical"))
    else:
        experimental.append(None)
        variograms.append(bt.Variogram([("spherical", indicator.var(), 0.5)]))
for name, v in zip(names, variograms):
    s = v.structures[0]
    print(f"{name:<13}nugget {v.nugget:.3f}, sill {s.sill:.3f}, range {s.range:.2f} km")

Out:

Argovian     nugget 0.013, sill 0.135, range 0.79 km
Kimmeridgian nugget 0.077, sill 0.152, range 1.62 km
Sequanian    nugget 0.015, sill 0.176, range 0.61 km
Portlandian  nugget 0.000, sill 0.011, range 0.50 km
Quaternary   nugget 0.000, sill 0.172, range 0.43 km
h = np.linspace(0, 1.5, 61)
fig, axes = plt.subplots(1, 5, figsize=(14, 3.2), layout="constrained", sharey=True)
for ax, name, e, v in zip(axes, names, experimental, variograms):
    if e is not None:
        ax.plot(e.lags, e.gammas, "o", color=GRAY, ms=4, label="experimental")
    ax.plot(h, v.gamma(h), color=ACCENT, label="model")
    ax.set(title=name, xlabel="lag (km)")
axes[0].set_ylabel("indicator semivariance")
axes[0].legend(loc="lower right")
save(fig, "variograms")

Argovian, Kimmeridgian, Sequanian, Portlandian, Quaternary

realizations

simulate returns a summary over n realizations: probabilities holds each type's frequency, one row per node, most_likely the most frequent type, and entropy, scaled to [0, 1], how evenly the realizations disagree. keep=True keeps the maps.

sis = bt.SIS(variograms, bt.Search(radius=1.5, max_samples=16)).fit(train, rock)
summary = sis.simulate(grid, n=20, seed=3, keep=True)
maps = summary.realizations
shares = np.array([rock_types.shares(m) for m in maps])
print(f"{'':<13}{'true grid':>10}{'mean':>7}{'min':>7}{'max':>7}")
for k, name in enumerate(names):
    print(
        f"{name:<13}{np.mean(true_rock == k):10.2f}{shares[:, k].mean():7.2f}"
        f"{shares[:, k].min():7.2f}{shares[:, k].max():7.2f}"
    )
match = np.mean(maps == true_rock, axis=1)
print(f"a realization matches the true rock type at {match.min():.0%} to {match.max():.0%} of the nodes")
print(
    f"the most likely type matches at {np.mean(summary.most_likely == true_rock):.0%}; "
    f"mean entropy {summary.entropy.mean():.2f}"
)

Out:

              true grid   mean    min    max
Argovian           0.20   0.16   0.14   0.19
Kimmeridgian       0.34   0.44   0.39   0.49
Sequanian          0.27   0.24   0.20   0.28
Portlandian        0.05   0.02   0.01   0.03
Quaternary         0.13   0.14   0.12   0.16
a realization matches the true rock type at 53% to 60% of the nodes
the most likely type matches at 64%; mean entropy 0.38
cmap, norm = bt.plot.category_colors(rock_types)
xy = grid.coords[:, :2].T
fig, axes = plt.subplots(2, 3, figsize=(12, 9.5), layout="constrained")
categorical = [(true_rock, "True rock types"), (maps[0], "Realization 1"), (maps[1], "Realization 2")]
for ax, (cats, title) in zip(axes[0], categorical):
    ax.scatter(*xy, c=cats, cmap=cmap, norm=norm, s=7, marker="s", linewidths=0)
    ax.set_title(title)
axes[1, 0].scatter(*xy, c=summary.most_likely, cmap=cmap, norm=norm, s=7, marker="s", linewidths=0)
axes[1, 0].set_title("Most likely over 20 realizations")
for ax, values, title, label, color in (
    (axes[1, 1], summary.probabilities[:, 0], "P(Argovian)", "probability", "cividis"),
    (axes[1, 2], summary.entropy, "Entropy", "entropy", "Greys"),
):
    im = ax.scatter(*xy, c=values, s=7, marker="s", linewidths=0, vmin=0, vmax=1, cmap=color)
    ax.set_title(title)
    fig.colorbar(im, ax=ax, shrink=0.8, orientation="horizontal", label=label)
for ax in axes.flat:
    ax.set_aspect("equal")
    ax.set(xlabel="X (km)", ylabel="Y (km)")
bt.plot.category_legend(rock_types, fig, loc="outside lower center", ncol=5)
save(fig, "realizations")

True rock types, Realization 1, Realization 2, Most likely over 20 realizations, P(Argovian), Entropy

each realization matches the true rock type at 53 % to 60 % of the nodes, and two realizations differ wherever the samples leave room. the proportions drift from the samples: kimmeridgian, whose indicator has the longest range, grows to 0.44 on average against 0.33 in the samples, while argovian and portlandian shrink. no realization recovers portlandian's 5 % of the area from 3 of 259 samples. the most likely type matches at 64 %, more than any realization, though it is a smooth estimate and no possible map looks like it. entropy peaks at the contacts and far from the samples.

Total running time of the script: ( 0 minutes 0.939 seconds)

Download Python source code: example_07_06.py

Download Jupyter notebook: example_07_06.ipynb

Gallery generated by mkdocs-gallery